﻿#!/usr/bin/env python
# -*- coding: utf-8 -*-

"""
Middleware layer that communicates between editor and Zen Coding.
This layer describes all available Zen Coding actions, like 
"Expand Abbreviation".
@author Sergey Chikuyonok (serge.che@gmail.com)
@link http://chikuyonok.ru
"""
from zencoding import zen_core as zen_coding
from zencoding import html_matcher
import re

def find_abbreviation(editor):
        """
        Search for abbreviation in editor from current caret position
        @param editor: Editor instance
        @type editor: ZenEditor
        @return: str
        """
        start, end = editor.get_selection_range()
        if start != end:
                # abbreviation is selected by user
                return editor.get_content()[start, end];
        
        # search for new abbreviation from current caret position
        cur_line_start, cur_line_end = editor.get_current_line_range()
        return zen_coding.extract_abbreviation(editor.get_content()[cur_line_start:start])

def expand_abbreviation(editor, syntax=None, profile_name=None):
        """
        Find from current caret position and expand abbreviation in editor
        @param editor: Editor instance
        @type editor: ZenEditor
        @param syntax: Syntax type (html, css, etc.)
        @type syntax: str
        @param profile_name: Output profile name (html, xml, xhtml)
        @type profile_name: str
        @return: True if abbreviation was expanded successfully
        """
        if syntax is None: syntax = editor.get_syntax()
        if profile_name is None: profile_name = editor.get_profile_name()
        
        range_start, caret_pos = editor.get_selection_range()
        abbr = find_abbreviation(editor)
        content = ''
                
        if abbr:
                content = zen_coding.expand_abbreviation(abbr, syntax, profile_name)
                if content:
                        editor.replace_content(content, caret_pos - len(abbr), caret_pos)
                        return True
        
        return False

def expand_abbreviation_with_tab(editor, syntax, profile_name='xhtml'):
        """
        A special version of <code>expandAbbreviation</code> function: if it can't
        find abbreviation, it will place Tab character at caret position
        @param editor: Editor instance
        @type editor: ZenEditor
        @param syntax: Syntax type (html, css, etc.)
        @type syntax: str
        @param profile_name: Output profile name (html, xml, xhtml)
        @type profile_name: str
        """
        if not expand_abbreviation(editor, syntax, profile_name):
                editor.replace_content(zen_coding.get_variable('indentation'), editor.get_caret_pos())

def match_pair(editor, direction='out', syntax=None):
        """
        Find and select HTML tag pair
        @param editor: Editor instance
        @type editor: ZenEditor
        @param direction: Direction of pair matching: 'in' or 'out'. 
        @type direction: str 
        """
        direction = direction.lower()
        if syntax is None: syntax = editor.get_profile_name()
        
        range_start, range_end = editor.get_selection_range()
        cursor = range_end
        content = editor.get_content()
        rng = None
        
        old_open_tag = html_matcher.last_match['opening_tag']
        old_close_tag = html_matcher.last_match['closing_tag']
        
        if direction == 'in' and old_open_tag and range_start != range_end:
#               user has previously selected tag and wants to move inward
                if not old_close_tag:
#                       unary tag was selected, can't move inward
                        return False
                elif old_open_tag.start == range_start:
                        if content[old_open_tag.end] == '<':
#                               test if the first inward tag matches the entire parent tag's content
                                _r = html_matcher.find(content, old_open_tag.end + 1, syntax)
                                if _r[0] == old_open_tag.end and _r[1] == old_close_tag.start:
                                        rng = html_matcher.match(content, old_open_tag.end + 1, syntax)
                                else:
                                        rng = (old_open_tag.end, old_close_tag.start)
                        else:
                                rng = (old_open_tag.end, old_close_tag.start)
                else:
                        new_cursor = content[0:old_close_tag.start].find('<', old_open_tag.end)
                        search_pos = new_cursor + 1 if new_cursor != -1 else old_open_tag.end
                        rng = html_matcher.match(content, search_pos, syntax)
        else:
                rng = html_matcher.match(content, cursor, syntax)
        
        if rng and rng[0] is not None:
                editor.create_selection(rng[0], rng[1])
                return True
        else:
                return False

def match_pair_inward(editor):
        return match_pair(editor, 'in')
        
def match_pair_outward(editor):
        return match_pair(editor, 'out')

def narrow_to_non_space(text, start, end):
        """
        Narrow down text indexes, adjusting selection to non-space characters
        @type text: str
        @type start: int
        @type end: int
        @return: list
        """
        # narrow down selection until first non-space character
        while start < end:
                if not text[start].isspace():
                        break
                        
                start += 1
        
        while end > start:
                end -= 1
                if not text[end].isspace():
                        end += 1
                        break
                
        return start, end

def wrap_with_abbreviation(editor, abbr, syntax=None, profile_name=None):
        """
        Wraps content with abbreviation
        @param editor: Editor instance
        @type editor: ZenEditor
        @param syntax: Syntax type (html, css, etc.)
        @type syntax: str
        @param profile_name: Output profile name (html, xml, xhtml)
        @type profile_name: str
        """
        if not abbr: return None 
        
        if syntax is None: syntax = editor.get_syntax()
        if profile_name is None: profile_name = editor.get_profile_name()
        
        start_offset, end_offset = editor.get_selection_range()
        content = editor.get_content()
        
        if start_offset == end_offset:
                # no selection, find tag pair
                rng = html_matcher.match(content, start_offset, profile_name)
                
                if rng[0] is None: # nothing to wrap
                        return None
                else:
                        start_offset, end_offset = rng
                        
        start_offset, end_offset = narrow_to_non_space(content, start_offset, end_offset)
        line_bounds = get_line_bounds(content, start_offset)
        padding = get_line_padding(content[line_bounds[0]:line_bounds[1]])
        
        new_content = content[start_offset:end_offset]
        result = zen_coding.wrap_with_abbreviation(abbr, unindent_text(new_content, padding), syntax, profile_name)
        
        if result:
                editor.replace_content(result, start_offset, end_offset)

def unindent(editor, text):
        """
        Unindent content, thus preparing text for tag wrapping
        @param editor: Editor instance
        @type editor: ZenEditor
        @param text: str
        @return str
        """
        return unindent_text(text, get_current_line_padding(editor))

def unindent_text(text, pad):
        """
        Removes padding at the beginning of each text's line
        @type text: str
        @type pad: str
        """
        lines = zen_coding.split_by_lines(text)
        
        for i,line in enumerate(lines):
                if line.startswith(pad):
                        lines[i] = line[len(pad):]
        
        return zen_coding.get_newline().join(lines)

def get_current_line_padding(editor):
        """
        Returns padding of current editor's line
        @return str
        """
        return get_line_padding(editor.get_current_line())

def get_line_padding(line):
        """
        Returns padding of current editor's line
        @return str
        """
        m = re.match(r'^(\s+)', line)
        return m and m.group(0) or ''

def find_new_edit_point(editor, inc=1, offset=0):
        """
        Search for new caret insertion point
        @param editor: Editor instance
        @type editor: ZenEditor
        @param inc: Search increment: -1 — search left, 1 — search right
        @param offset: Initial offset relative to current caret position
        @return: -1 if insertion point wasn't found
        """
        cur_point = editor.get_caret_pos() + offset
        content = editor.get_content()
        max_len = len(content)
        next_point = -1
        re_empty_line = r'^\s+$'
        
        def get_line(ix):
                start = ix
                while start >= 0:
                        c = content[start]
                        if c == '\n' or c == '\r': break
                        start -= 1
                
                return content[start:ix]
                
        while cur_point < max_len and cur_point > 0:
                cur_point += inc
                cur_char = content[cur_point]
                next_char = content[cur_point + 1]
                prev_char = content[cur_point - 1]
                
                if cur_char in '"\'':
                        if next_char == cur_char and prev_char == '=':
                                # empty attribute
                                next_point = cur_point + 1
                elif cur_char == '>' and next_char == '<':
                        # between tags
                        next_point = cur_point + 1
                elif cur_char in '\r\n':
                        # empty line
                        if re.search(re_empty_line, get_line(cur_point - 1)):
                                next_point = cur_point
                
                if next_point != -1: break
        
        return next_point

def prev_edit_point(editor):
        """
        Move caret to previous edit point
        @param editor: Editor instance
        @type editor: ZenEditor
        """
        cur_pos = editor.get_caret_pos()
        new_point = find_new_edit_point(editor, -1)
                
        if new_point == cur_pos:
                # we're still in the same point, try searching from the other place
                new_point = find_new_edit_point(editor, -1, -2)
        
        if new_point != -1:
                editor.set_caret_pos(new_point)

def next_edit_point(editor):
        """
        Move caret to next edit point
        @param editor: Editor instance
        @type editor: ZenEditor
        """ 
        new_point = find_new_edit_point(editor, 1)
        if new_point != -1:
                editor.set_caret_pos(new_point)

def insert_formatted_newline(editor, mode='html'):
        """
        Inserts newline character with proper indentation
        @param editor: Editor instance
        @type editor: ZenEditor
        @param mode: Syntax mode (only 'html' is implemented)
        @type mode: str
        """
        caret_pos = editor.get_caret_pos()
        nl = zen_coding.get_newline()
        pad = zen_coding.get_variable('indentation')
                
        if mode == 'html':
                # let's see if we're breaking newly created tag
                pair = html_matcher.get_tags(editor.get_content(), editor.get_caret_pos(), editor.get_profile_name())
                
                if pair[0] and pair[1] and pair[0]['type'] == 'tag' and pair[0]['end'] == caret_pos and pair[1]['start'] == caret_pos:
                        editor.replace_content(nl + pad + zen_coding.get_caret_placeholder() + nl, caret_pos)
                else:
                        editor.replace_content(nl, caret_pos)
        else:
                editor.replace_content(nl, caret_pos)

def select_line(editor):
        """
        Select line under cursor
        @param editor: Editor instance
        @type editor: ZenEditor
        """
        start, end = editor.get_current_line_range();
        editor.create_selection(start, end)

def go_to_matching_pair(editor):
        """
        Moves caret to matching opening or closing tag
        @param editor: Editor instance
        @type editor: ZenEditor
        """
        content = editor.get_content()
        caret_pos = editor.get_caret_pos()
        
        if content[caret_pos] == '<': 
                # looks like caret is outside of tag pair  
                caret_pos += 1
                
        tags = html_matcher.get_tags(content, caret_pos, editor.get_profile_name())
                
        if tags and tags[0]:
                # match found
                open_tag, close_tag = tags
                        
                if close_tag: # exclude unary tags
                        if open_tag['start'] <= caret_pos and open_tag['end'] >= caret_pos:
                                editor.set_caret_pos(close_tag['start'])
                        elif close_tag['start'] <= caret_pos and close_tag['end'] >= caret_pos:
                                editor.set_caret_pos(open_tag['start'])
                                

def merge_lines(editor):
        """
        Merge lines spanned by user selection. If there's no selection, tries to find
        matching tags and use them as selection
        @param editor: Editor instance
        @type editor: ZenEditor
        """
        start, end = editor.get_selection_range()
        if start == end:
                # find matching tag
                pair = html_matcher.match(editor.get_content(), editor.get_caret_pos(), editor.get_profile_name())
                if pair and pair[0] is not None:
                        start, end = pair
        
        if start != end:
                # got range, merge lines
                text = editor.get_content()[start:end]
                lines = map(lambda s: re.sub(r'^\s+', '', s), zen_coding.split_by_lines(text))
                text = re.sub(r'\s{2,}', ' ', ''.join(lines))
                editor.replace_content(text, start, end)
                editor.create_selection(start, start + len(text))

def toggle_comment(editor):
        """
        Toggle comment on current editor's selection or HTML tag/CSS rule
        @type editor: ZenEditor
        """
        syntax = editor.get_syntax()
        if syntax == 'css':
                return toggle_css_comment(editor)
        else:
                return toggle_html_comment(editor)

def toggle_html_comment(editor):
        """
        Toggle HTML comment on current selection or tag
        @type editor: ZenEditor
        @return: True if comment was toggled
        """
        start, end = editor.get_selection_range()
        content = editor.get_content()
                
        if start == end:
                # no selection, find matching tag
                pair = html_matcher.get_tags(content, editor.get_caret_pos(), editor.get_profile_name())
                if pair and pair[0]: # found pair
                        start = pair[0].start
                        end = pair[1] and pair[1].end or pair[0].end
        
        return generic_comment_toggle(editor, '<!--', '-->', start, end)

def toggle_css_comment(editor):
        """
        Simple CSS commenting
        @type editor: ZenEditor
        @return: True if comment was toggled
        """
        start, end = editor.get_selection_range()
        
        if start == end:
                # no selection, get current line
                start, end = editor.get_current_line_range()

                # adjust start index till first non-space character
                start, end = narrow_to_non_space(editor.get_content(), start, end)
        
        return generic_comment_toggle(editor, '/*', '*/', start, end)

def search_comment(text, pos, start_token, end_token):
        """
        Search for nearest comment in <code>str</code>, starting from index <code>from</code>
        @param text: Where to search
        @type text: str
        @param pos: Search start index
        @type pos: int
        @param start_token: Comment start string
        @type start_token: str
        @param end_token: Comment end string
        @type end_token: str
        @return: None if comment wasn't found, list otherwise
        """
        start_ch = start_token[0]
        end_ch = end_token[0]
        comment_start = -1
        comment_end = -1
        
        def has_match(tx, start):
                return text[start:start + len(tx)] == tx
        
                
        # search for comment start
        while pos:
                pos -= 1
                if text[pos] == start_ch and has_match(start_token, pos):
                        comment_start = pos
                        break
        
        if comment_start != -1:
                # search for comment end
                pos = comment_start
                content_len = len(text)
                while content_len >= pos:
                        pos += 1
                        if text[pos] == end_ch and has_match(end_token, pos):
                                comment_end = pos + len(end_token)
                                break
        
        if comment_start != -1 and comment_end != -1:
                return comment_start, comment_end
        else:
                return None

def generic_comment_toggle(editor, comment_start, comment_end, range_start, range_end):
        """
        Generic comment toggling routine
        @type editor: ZenEditor
        @param comment_start: Comment start token
        @type comment_start: str
        @param comment_end: Comment end token
        @type comment_end: str
        @param range_start: Start selection range
        @type range_start: int
        @param range_end: End selection range
        @type range_end: int
        @return: bool
        """
        content = editor.get_content()
        caret_pos = [editor.get_caret_pos()]
        new_content = None
                
        def adjust_caret_pos(m):
                caret_pos[0] -= len(m.group(0))
                return ''
                
        def remove_comment(text):
                """
                Remove comment markers from string
                @param {Sting} str
                @return {String}
                """
                text = re.sub(r'^' + re.escape(comment_start) + r'\s*', adjust_caret_pos, text)
                return re.sub(r'\s*' + re.escape(comment_end) + '$', '', text)
        
        def has_match(tx, start):
                return content[start:start + len(tx)] == tx
        
        # first, we need to make sure that this substring is not inside comment
        comment_range = search_comment(content, caret_pos[0], comment_start, comment_end)
        
        if comment_range and comment_range[0] <= range_start and comment_range[1] >= range_end:
                # we're inside comment, remove it
                range_start, range_end = comment_range
                new_content = remove_comment(content[range_start:range_end])
        else:
                # should add comment
                # make sure that there's no comment inside selection
                new_content = '%s %s %s' % (comment_start, re.sub(re.escape(comment_start) + r'\s*|\s*' + re.escape(comment_end), '', content[range_start:range_end]), comment_end)
                        
                # adjust caret position
                caret_pos[0] += len(comment_start) + 1

        # replace editor content
        if new_content is not None:
                d = caret_pos[0] - range_start
                new_content = new_content[0:d] + zen_coding.get_caret_placeholder() + new_content[d:]
                editor.replace_content(unindent(editor, new_content), range_start, range_end)
                return True
        
        return False

def split_join_tag(editor, profile_name=None):
        """
        Splits or joins tag, e.g. transforms it into a short notation and vice versa:
        <div></div> → <div /> : join
        <div /> → <div></div> : split
        @param editor: Editor instance
        @type editor: ZenEditor
        @param profile_name: Profile name
        @type profile_name: str
        """
        caret_pos = editor.get_caret_pos()
        profile = zen_coding.get_profile(profile_name or editor.get_profile_name())
        caret = zen_coding.get_caret_placeholder()

        # find tag at current position
        pair = html_matcher.get_tags(editor.get_content(), caret_pos, profile_name or editor.get_profile_name())
        if pair and pair[0]:
                new_content = pair[0].full_tag
                
                if pair[1]: # join tag
                        closing_slash = ''
                        if profile['self_closing_tag'] is True:
                                closing_slash = '/'
                        elif profile['self_closing_tag'] == 'xhtml':
                                closing_slash = ' /'
                                
                        new_content = re.sub(r'\s*>$', closing_slash + '>', new_content)
                        editor.replace_content(new_content + caret, pair[0].start, pair[1].end)
                else: # split tag
                        nl = zen_coding.get_newline()
                        pad = zen_coding.get_variable('indentation')
                        
                        # define tag content depending on profile
                        tag_content = profile['tag_nl'] is True and nl + pad + caret + nl or caret
                        
                        new_content = '%s%s</%s>' % (re.sub(r'\s*\/>$', '>', new_content), tag_content, pair[0].name)
                        editor.replace_content(new_content, pair[0].start, pair[0].end)
                
                return True
        else:
                return False
        

def get_line_bounds(text, pos):
        """
        Returns line bounds for specific character position
        @type text: str
        @param pos: Where to start searching
        @type pos: int
        @return: list
        """
        start = 0
        end = len(text) - 1
        
        # search left
        for i in range(pos - 1, 0, -1):
                if text[i] in '\n\r':
                        start = i + 1
                        break
                
        # search right
        for i in range(pos, len(text)):
                if text[i] in '\n\r':
                        end = i
                        break
                
        return start, end

def remove_tag(editor):
        """
        Gracefully removes tag under cursor
        @type editor: ZenEditor
        """
        caret_pos = editor.get_caret_pos()
        content = editor.get_content()
                
        # search for tag
        pair = html_matcher.get_tags(content, caret_pos, editor.get_profile_name())
        if pair and pair[0]:
                if not pair[1]:
                        # simply remove unary tag
                        editor.replace_content(zen_coding.get_caret_placeholder(), pair[0].start, pair[0].end)
                else:
                        tag_content_range = narrow_to_non_space(content, pair[0].end, pair[1].start)
                        start_line_bounds = get_line_bounds(content, tag_content_range[0])
                        start_line_pad = get_line_padding(content[start_line_bounds[0]:start_line_bounds[1]])
                        tag_content = content[tag_content_range[0]:tag_content_range[1]]
                                
                        tag_content = unindent_text(tag_content, start_line_pad)
                        editor.replace_content(zen_coding.get_caret_placeholder() + tag_content, pair[0].start, pair[1].end)
                
                return True
        else:
                return False